19-7 作业讲解:Role关联查询Permission&拦截器序列化输出
一、创建角色时关联权限
1.1 DTO设置与Transform
背景知识
在NestJS中,DTO(Data Transfer Object)用于定义接口的输入输出数据结构。结合class-transformer
和class-validator
可以实现数据转换和验证。
代码实现
import { IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { CreatePermissionDto } from './create-permission.dto';
export class CreateRoleDto {
@IsOptional()
@Type(() => CreatePermissionDto) // 指定嵌套DTO的转换类型
permissions?: CreatePermissionDto[];
}
typescript
配置全局管道
// main.ts
import { ValidationPipe } from '@nestjs/common';
app.useGlobalPipes(
new ValidationPipe({
transform: true, // 启用自动类型转换
transformOptions: {
enableImplicitConversion: true, // 允许隐式类型转换(如字符串转日期)
},
}),
);
typescript
常见问题
- 问题1:
@Type
未生效
解决:确保安装了class-transformer
并在DTO中正确导入。 - 问题2:嵌套DTO验证失败
解决:在嵌套DTO中也需添加class-validator
装饰器。
💡提示:transform: true
还会自动将查询参数转换为DTO类型,如@Query()
。
1.2 使用connectOrCreate实现关联创建
背景知识
Prisma的connectOrCreate
是处理关联关系的核心方法,适用于以下场景:
- 关联记录可能已存在(通过唯一字段判断)
- 需要避免重复创建相同数据
代码实现
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class RoleService {
constructor(private prisma: PrismaService) {}
async create(createRoleDto: CreateRoleDto) {
const { permissions, ...roleData } = createRoleDto;
return this.prisma.$transaction(async (prisma) => {
return prisma.role.create({
data: {
...roleData,
rolePermissions: {
create: permissions.map((permission) => ({
permission: {
connectOrCreate: {
where: { name: permission.name }, // 唯一字段判断
create: permission, // 不存在时创建
},
},
})),
},
},
});
});
}
}
typescript
关键实现点
- 解构分离
const { permissions, ...roleData } = createRoleDto;
typescript- 分离权限数据和角色基础数据,避免无效字段传入Prisma。
- 事务处理
this.prisma.$transaction(async (prisma) => { ... });
typescript- 确保角色和权限关联操作的原子性。
- connectOrCreate逻辑
where
: 基于permission.name
(唯一字段)查询是否已存在。create
: 不存在时创建新权限记录。
- 嵌套创建
- 通过
rolePermissions.create
建立角色与权限的多对多关联。
- 通过
最佳实践
- 唯一字段选择:优先使用业务意义的唯一字段(如
name
而非ID)。 - 错误处理:添加
try-catch
捕获PrismaClientKnownRequestError
(如唯一约束冲突)。
1.3 测试验证
测试用例
请求示例:
POST /roles
{
"name": "普通用户6",
"permissions": [
{"name": "log_read", "action": "read", "description": "读取日志"},
{"name": "log_update", "action": "update", "description": "更新日志"}
]
}
json
验证步骤
- 首次创建
- 检查
permissions
表:新增log_read
和log_update
记录。 - 检查
role_permissions
表:正确关联角色ID和权限ID。
- 检查
- 重复创建
- 再次发送相同请求:
{ "name": "普通用户7", "permissions": [ {"name": "log_read", "action": "read"} ] }
json - 验证结果:
permissions
表无新增记录(复用已有权限)。role_permissions
表新增关联关系。
- 再次发送相同请求:
- 数据库检查
-- 查询权限关联 SELECT r.name AS role_name, p.name AS permission_name FROM roles r JOIN role_permissions rp ON r.id = rp.role_id JOIN permissions p ON rp.permission_id = p.id;
sql
自动化测试
// role.e2e-spec.ts
it('should create role with permissions', async () => {
const response = await request(app.getHttpServer())
.post('/roles')
.send({
name: '测试角色',
permissions: [{ name: 'test_permission', action: 'read' }],
})
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe('测试角色');
});
typescript
扩展知识
- Prisma事务扩展
- 使用
interactiveTransactions
处理复杂事务:await this.prisma.$transaction(async (prisma) => { const role = await prisma.role.create({ ... }); await prisma.log.create({ data: { event: 'ROLE_CREATED' } }); return role; });
typescript
- 使用
- 性能优化
- 批量查询优化:使用
prisma.$queryRaw
执行原生SQL减少查询次数。 - 索引优化:为
permission.name
添加数据库索引。
- 批量查询优化:使用
- 替代方案
- upsert:若需更新已有权限,可用
prisma.permission.upsert
替代connectOrCreate
。
- upsert:若需更新已有权限,可用
💡提示:Prisma官方文档推荐使用Relation Queries深入学习关联操作。
二、查询角色时关联权限并序列化
2.1 关联查询实现
背景知识
在Prisma中,include
是实现关联查询的核心方法,它允许我们通过嵌套的方式一次性获取关联数据,避免了N+1查询问题。这在角色-权限这种多对多关系中尤为重要。
代码实现详解
async findOne(id: number) {
const role = await this.prisma.role.findUnique({
where: { id }, // 通过ID查询特定角色
include: {
rolePermissions: { // 包含角色权限关联表
include: {
permission: true // 进一步包含权限详情
}
}
}
});
return plainToClass(PublicRoleDto, role); // 使用DTO转换结果
}
typescript
关键点解析
- 多级include:
- 第一级:
rolePermissions
获取角色-权限关联记录 - 第二级:
permission
获取具体的权限详情
- 第一级:
- 性能考虑:
- 默认情况下Prisma会返回所有字段,可通过
select
优化:include: { rolePermissions: { select: { permission: { select: { name: true } // 只返回权限名称 } } } }
typescript
- 默认情况下Prisma会返回所有字段,可通过
常见错误
- 错误1:
include
嵌套层级错误
解决:确保关联路径与Prisma Schema定义一致 - 错误2:循环引用导致栈溢出
解决:使用@Type(() => Class)
装饰器处理循环依赖
💡提示:在GraphQL场景中,可以考虑使用DataLoader来优化关联查询性能。
2.2 DTO序列化输出
深度解析
PublicRoleDto
的核心作用是:
- 控制API输出字段
- 转换数据结构
- 防止敏感信息泄露
增强版DTO实现
import { Expose, Transform } from 'class-transformer';
export class PublicRoleDto {
@Expose()
id: number;
@Expose()
name: string;
@Expose()
@Transform(({ obj }) =>
obj.rolePermissions.map(rp => rp.permission.name),
{ toClassOnly: true }
)
permissions: string[];
@Expose()
createdAt: Date;
@Expose()
@Transform(({ value }) => value?.toISOString(), { toPlainOnly: true })
updatedAt: string;
}
typescript
高级特性
- 条件序列化:
@Expose() @Transform(({ value }) => isAdmin ? value : undefined) sensitiveField: string;
typescript - 分组控制:
@Expose({ groups: ['admin'] }) internalNote: string;
typescript
常见问题解决方案
问题现象 | 原因 | 解决方案 |
---|---|---|
字段缺失 | 未加@Expose() | 检查所有需要输出的字段 |
日期格式错误 | 未转换Date类型 | 使用@Transform转换 |
循环引用 | 双向关联未处理 | 添加@Type装饰器 |
2.3 查询结果验证
完整测试方案
- 单元测试:
it('should return role with permissions', async () => { const mockRole = { id: 1, name: 'admin', rolePermissions: [{ permission: { name: 'user_create' } }] }; jest.spyOn(prisma.role, 'findUnique').mockResolvedValue(mockRole); const result = await service.findOne(1); expect(result.permissions).toEqual(['user_create']); });
typescript - 集成测试:
describe('GET /roles/:id', () => { it('should return 200 with permissions', async () => { const res = await request(app) .get('/roles/1') .expect(200); expect(res.body).toMatchObject({ id: expect.any(Number), permissions: expect.arrayContaining([ expect.any(String) ]) }); }); });
typescript - 数据库断言:
// 验证数据库状态 const dbRole = await prisma.role.findUnique({ where: { id: 1 }, include: { rolePermissions: true } }); expect(dbRole.rolePermissions.length).toBeGreaterThan(0);
typescript
响应示例
成功响应:
{
"id": 14,
"name": "普通用户6",
"permissions": ["log_read", "log_update"],
"createdAt": "2023-08-20T03:00:00.000Z",
"updatedAt": "2023-08-20T03:00:00.000Z"
}
json
错误响应:
{
"statusCode": 404,
"message": "Role not found"
}
json
扩展知识:性能优化方案
- 字段过滤:
include: { rolePermissions: { select: { permission: { select: { name: true } } }, take: 10 // 限制关联记录数量 } }
typescript - 缓存策略:
@UseInterceptors(CacheInterceptor) @CacheTTL(60) // 缓存60秒 async findOne(id: number) { // ... }
typescript - 批量查询优化:
async findManyWithPermissions(ids: number[]) { return this.prisma.$transaction([ this.prisma.role.findMany({ where: { id: { in: ids } }}), this.prisma.rolePermission.findMany({ where: { roleId: { in: ids } }, include: { permission: true } }) ]); }
typescript
💡提示:对于超大规模数据,考虑使用Prisma的paginate
扩展或直接使用SQL窗口函数。
三、作业:用户权限关联改造
3.1 作业要求
业务背景
在现代RBAC(基于角色的访问控制)系统中,用户通过角色间接拥有权限。前端界面通常需要直接展示用户的所有权限列表,而非让前端自行拼接角色对应的权限。
核心需求
- 改造现有用户查询接口,使其直接返回用户拥有的所有权限名称列表
- 确保权限数据经过正确去重
- 保持API响应时间在100ms以内(假设权限数量<100)
- 完善错误处理机制
技术约束
- 使用Prisma作为ORM
- 保持与现有角色权限实现风格一致
- 支持NestJS的拦截器机制
3.2 实现步骤
3.2.1 模型层改造
Prisma Schema优化
model User {
id Int @id @default(autoincrement())
name String
email String @unique
roles Role[] // 多对多关系
@@map("users")
}
model Role {
id Int @id @default(autoincrement())
name String @unique
users User[] // 反向关系
rolePermissions RolePermission[] // 角色-权限关联
@@map("roles")
}
model RolePermission {
id Int @id @default(autoincrement())
role Role @relation(fields: [roleId], references: [id])
permission Permission @relation(fields: [permissionId], references: [id])
roleId Int
permissionId Int
@@map("role_permissions")
}
prisma
关键改进
- 显式定义多对多关系的中间表
- 添加
@@map
确保表名符合数据库命名规范 - 完善关系字段的
@relation
注解
3.2.2 Service层实现
增强版查询方法
async findOneWithPermissions(id: number) {
const user = await this.prisma.user.findUnique({
where: { id },
include: {
roles: {
include: {
rolePermissions: {
include: {
permission: {
select: { name: true } // 只获取必要字段
}
}
}
}
}
},
rejectOnNotFound: true // 自动抛出404错误
});
// 手动去重逻辑
const permissionSet = new Set<string>();
user.roles.forEach(role => {
role.rolePermissions.forEach(rp => {
permissionSet.add(rp.permission.name);
});
});
return {
...user,
permissions: Array.from(permissionSet)
};
}
typescript
性能优化点
- 使用
select
限制返回字段 - 在数据库查询后立即进行内存去重
- 利用
rejectOnNotFound
简化错误处理
3.2.3 权限拦截器(高级版)
可配置拦截器
@Injectable()
export class PermissionsInterceptor implements NestInterceptor {
constructor(private readonly config: { mergeStrategy: 'union' | 'intersection' } = { mergeStrategy: 'union' }) {}
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
map(data => this.transformData(data)),
catchError(err => throwError(() => this.transformError(err)))
);
}
private transformData(data: any) {
if (!data?.roles) return data;
const permissions = new Set<string>();
data.roles.forEach(role => {
role.rolePermissions?.forEach(rp => {
if (rp.permission) {
permissions.add(rp.permission.name);
}
});
});
return { ...data, permissions: Array.from(permissions) };
}
private transformError(err: any) {
if (err instanceof Prisma.NotFoundError) {
throw new NotFoundException('User not found');
}
throw err;
}
}
typescript
控制器集成
@Controller('users')
export class UsersController {
@UseInterceptors(new PermissionsInterceptor({ mergeStrategy: 'union' }))
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOneWithPermissions(+id);
}
}
typescript
3.3 验收标准
自动化测试用例
describe('User Permission Integration', () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
await app.init();
});
it('GET /users/1 should return permissions', async () => {
// 准备测试数据
await prisma.$transaction([
prisma.permission.createMany({
data: [{ name: 'p1' }, { name: 'p2' }]
}),
prisma.role.create({
data: {
name: 'r1',
rolePermissions: {
create: [{ permission: { connect: { name: 'p1' } } }]
}
}
}),
prisma.user.create({
data: {
name: 'test',
roles: { connect: { name: 'r1' } }
}
})
]);
// 执行测试
const response = await request(app.getHttpServer())
.get('/users/1')
.expect(200);
// 验证结果
expect(response.body).toEqual({
id: 1,
name: 'test',
permissions: ['p1']
});
});
afterAll(async () => {
await app.close();
});
});
typescript
性能指标
场景 | 预期响应时间 | 数据库查询次数 |
---|---|---|
用户有5个角色,每个角色10个权限 | <50ms | 1次主查询+1次权限去重 |
用户不存在 | <10ms | 1次快速失败查询 |
错误处理规范
扩展实践
1. 权限缓存方案
// 使用Redis缓存权限数据
@Injectable()
export class PermissionCache {
constructor(private redis: RedisService) {}
async getUserPermissions(userId: number) {
const cached = await this.redis.get(`user:${userId}:permissions`);
if (cached) return JSON.parse(cached);
const permissions = await this.fetchFromDB(userId);
await this.redis.set(`user:${userId}:permissions`, JSON.stringify(permissions), 'EX', 3600);
return permissions;
}
}
typescript
2. 批量查询优化
// 批量获取用户权限
async getUsersPermissions(userIds: number[]) {
return this.prisma.$transaction([
this.prisma.user.findMany({
where: { id: { in: userIds } },
include: { roles: { include: { rolePermissions: { include: { permission: true } } } } }
}),
// 额外的优化查询...
]);
}
typescript
3. 权限变更通知
// 使用事件总线通知权限变更
@Injectable()
export class PermissionService {
constructor(private eventBus: EventBus) {}
async updateRolePermissions(roleId: number) {
// ...更新逻辑
this.eventBus.publish(new PermissionUpdatedEvent(roleId));
}
}
typescript
通过以上改造,系统将获得:
- 更清晰的权限数据流向
- 更好的查询性能
- 更健壮的错误处理
- 更灵活的扩展能力
↑